Skip to content

fix(types): accept an explicit undefined wherever a consumer writes an optional member - #418

Draft
YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:rncss/types-optional-undefined
Draft

YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:rncss/types-optional-undefined

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

React Native declares its own optional props as ?: T | undefined, while the props this package adds are bare ?: string. Under exactOptionalPropertyTypes the two differ, and the difference falls on the most ordinary thing a consumer writes:

<View className={condition ? "p-4" : undefined} />
error TS2769: No overload matches this call.
  Overload 1 of 2, '(props: ViewProps): View', gave the following error.
    Type '{ className: string | undefined; }' is not assignable to type 'Readonly<ViewProps>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.

styled() has the same bare members: the className its unmapped overload adds, passThrough, and a mapping's nativeStyleMapping; so do the options a consumer writes for the compiler, Metro, the jest helper and useNativeCss. A consumer's own declaration that widens such a prop takes effect only when it comes before this package's in the program.

A mapping entry left undefined is also a runtime error: mappingToConfig keeps it, and nativeStyleMapping calls path.split(".") on it:

const StyledView = styled(View, {
  className: {
    target: "style",
    nativeStyleMapping: { color: "accessibilityLabel", backgroundColor: undefined },
  },
});

// .subject { color: blue; background-color: red; }
// The render throws "Cannot read properties of undefined (reading 'split')",
// and the render React retries has no backgroundColor.
<StyledView className="subject" />;

Solution

  • Every optional member of types.d.ts and runtime.types.ts, and of the compiler's, Metro's, the jest helper's and useNativeCss's options, takes | undefined, as React Native writes its own, except InlineStyleRecord's s?: never, which marks a key that must stay absent. The jest helper's options leave out logger, which the helper always sets itself.
  • The props a styled() mapping adds come from one StyledSourceProps type, which StyledProps, StyledReactElement and StyledComponent use, and which the unmapped overload uses with DefaultStyledConfiguration, the default mapping src/native/api.tsx and src/web/api.tsx both pass. They stay writable, as the component's own props are.
  • A mapping's keys are the style keys the compiler collects: React Native's, which an editor completes, or the camel-cased name of a -rn- declaration, so any string is a key. The type no longer resolves the mapping's target, and an entry may be left undefined, which mappingToConfig drops.

Tests

  • src/__tests__/types/optional-members.test.ts checks both declaration files and the option types with the type checker, the flag on, and fails on any optional member that rejects undefined. A type that resolves is read member by member, inherited, picked and mapped members included, down to the option types its members reach; a generic one is read as written, a mapped type over keyof T and a library type such as Partial over a type parameter among its forms. A listed type the file no longer declares fails the test. Its fixture holds each form, beside the ones that pass. Against main it reports 49 members, an inherited one under each type that inherits it; here none.
  • src/__tests__/native/_styled-types.tsx: a mapping takes any style key, a -rn- declaration's among them, and an entry left undefined, rejects a value naming no prop, and the class props stay writable; on main tsc fails the undefined entry and the writable mapped prop.
  • src/__tests__/types/styled.test.ts: the editor completes React Native's style keys whatever the target; it fails on main.
  • src/__tests__/native/styled.test.ios.tsx: a mapping entry set to undefined maps nothing. On main the case fails tsc, and its render throws.

Verification

Every ci.yml job passes on this head merged into main with #477's harness fix: lint, typecheck, the tests (1350 passed), both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.0, where CI pins 26.6.

A styled() mapping now costs the type checker about 8,100 instantiations against 35,200 on main, and tsc --noEmit over this repository makes 930,621 type instantiations against 979,442. The runs, and the snippet above on main and on this branch, are in this comment.

Known limits

  • A misspelled mapping key compiles, since a -rn- declaration can add any key to the style.
  • styled() over Reanimated's animated components still exceeds the type checker's depth (TS2589, TS2590), as on main, at about 15% fewer instantiations.
  • The package's own code has 11 errors under the flag, in six files; this PR's widening of CompilerOptions.logger and Config.nativeStyleMapping clears the 6 others main has.

Merge order

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The same image. A type declaration has no compiled twin; the evidence is that this story COMPILES, which it cannot against stock types.

FIXED — Each explicit-undefined slot is empty exactly like the omitted-prop slot beside it, and the resolved slot is filled.

before — 3.1.0-rc.0 after — with this PR

Apart from the typeface, the two frames are the same image: an explicit undefined renders as an omitted prop does on both, and only the types tell them apart.

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

A mapping entry left undefined — main against this PR

The snippet from the body, rendered through the repo's jest with a window.dispatchEvent stub, so React's report of the error it recovered from reaches console.error:

errors accessibilityLabel style
main (eda2b286) There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root., caused by Cannot read properties of undefined (reading 'split') #00f {}
this PR none #00f { backgroundColor: "#f00" }

Type-check cost — main against this PR

tsc --noEmit --extendedDiagnostics; the counts are the same on every run.

main (eda2b286) this PR
this repository: types 167,952 170,434
this repository: instantiations 979,442 930,621
this repository: check time, three runs a side, alternating 3.22–3.46 s 3.33–3.51 s
a file importing styled: instantiations 480,167 479,900
the same file with 13 styled() mappings over React Native's components: instantiations 937,533 585,765
each mapping, on average 35,182 8,143
three styled() mappings over Reanimated's Animated.View, Animated.Text and Animated.ScrollView: instantiations 23,018,446 19,619,557
the same file: check time, and errors 217.5 s; 2 × TS2589, 1 × TS2590 204.3 s; the same errors

The mapping type no longer resolves its target: its keys are React Native's style keys, which an editor completes, beside any string.

Windows 11 Pro 10.0.26200, AMD Ryzen 9 9950X3D, Node 26.8.1, TypeScript 6.0.3.

@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the rncss/types-optional-undefined branch from 6c6da94 to d10ad90 Compare October 11, 2026 12:35
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 20, 2026 09:53
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every prop this package adds fix(types): accept an explicit undefined on every prop and styled() option this package adds Oct 20, 2026
…s package declares

React Native declares its optional props as `?: T | undefined`, and the props
this package adds were bare `?: string`. Under exactOptionalPropertyTypes the
two differ, so `className={condition ? "p-4" : undefined}` failed to compile,
and declaration merging cannot widen an existing prop from the consumer side.

Every optional member of types.d.ts and runtime.types.ts now takes
`| undefined`, except InlineStyleRecord's `s?: never`, which marks a key that
must stay absent. The props a mapping adds come from one StyledSourceProps
type, which the unmapped overload uses with DefaultStyledConfiguration, the
default mapping both platforms pass.

A test walks both files with the type checker under the flag and fails on any
optional member that rejects undefined, so a member added later is covered too.
…an entry left undefined

StyledConfigurationObject called ResolveDotPath<T, ComponentProps<C>>, the
reverse of its declaration ResolveDotPath<T, Path>, so a string target's
mapping type was never and only the target:false branch's Record checked a
mapping. With the arguments in order, an entry may be left undefined, and
mappingToConfig drops it, where nativeStyleMapping called `path.split(".")` on
it and threw.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the rncss/types-optional-undefined branch from 8c8919f to 49ff1c9 Compare October 20, 2026 15:02
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every prop and styled() option this package adds fix(types): accept an explicit undefined on every prop, styled() option and mapping entry this package adds Oct 20, 2026
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every prop, styled() option and mapping entry this package adds fix(types): accept an explicit undefined on every added prop, styled() option and mapping entry Oct 20, 2026
…in a mapping

A nativeStyleMapping key names a key of the compiled style, which a -rn-
declaration can add under any name, so its keys stay open while React
Native's style keys complete; the type no longer resolves the target,
which halves its instantiations. The class props styled() adds stay
writable, and the compiler, Metro, jest, Babel and useNativeCss options
accept an explicit undefined.
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every added prop, styled() option and mapping entry fix(types): accept an explicit undefined wherever a consumer writes an optional member Oct 20, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant